到目前為止,雖然是可以記帳,可以正常新增一筆完整的帳目,還可以編輯、刪除。但是不是忘記了什麼東西?
啊對,就是記帳最重要的「記」這一回事,每次網頁刷新之後,之前的帳目就消失了。那這樣當然不行,都沒有好好記住,怎麼可以叫做記帳 😂
所以,要想辦法讓新增的帳目,可以記錄於「某個地方」,以便之後回頭查看,而這不必限定於使用
這邊為大家複習一下整個開發流程,可以用 PDCA 循環,涵蓋整個開發的週期:
一個完整功能可能有數個 PDCA 循環,因為單一項功能可能分成數小個更小的功能單位,每個更小的功能也會經過如此循環。接下來就以「記帳的帳目要記錄起來」為範例,一次介紹完整的 PDCA 循環吧。
首先,是把這個記帳 App 要能夠紀錄,於是寫了如下的使用者故事:
作為一名使用者
我希望可以「紀錄」每一筆帳目
以便下次回來回頭查找帳目
一樣請 AI 幫我列成 BDD feature 格式,轉出來的結果為:
Feature: 紀錄帳目
Scenario: 瀏覽已紀錄的帳目
Given 應用程序已啟動
When 我查看我的帳目紀錄
Then 我應該看到所有已紀錄的帳目
And 每一筆帳目應該包含金額和類別
這樣一看,只有包含 CRUD
的 R 而已,也就是只有 Reading 的功能。但沒有太大問題,因為我只要確保 App 初始狀態會有值,有辦法讀取到「上次」的紀錄,我先不用關心上次的紀錄怎麼來。
這樣功能的「範圍」就比較小且單純,只關注在取得資料後,正確渲染而已。
從上一步生成的 BDD ,一樣按照先前的方法,試著請 AI 幫我生成看看測試。但這裡有個小小的不同,還記得我們之前都是「先做功能」再寫測試嗎?
這裡想嘗試以經典的 BDD + TDD 開發方式,先寫測試,測試會處於未通過的狀態 🔴,因為我們根本還沒寫功能。接著再來實做程式碼,將功能實現,直至測試通過 🟢。
一樣先試著用比較粗略的指令,請 Cursor IDE 幫我實作測試看看:
幫我根據 Scenario: 瀏覽已紀錄的帳目 這項場景,
新增一筆測試在 @AccountingApp.test.tsx
結果 AI 給了我跟其他測試差不多的測試
// ... existing tests ...
it('displays recorded account entries correctly', () => {
whenRender();
// 模擬輸入第一筆帳目
whenInputNumber(50);
whenClickOK();
whenSelectCategory('飲食');
whenClickOK();
// 驗證已紀錄的帳目是否正確顯示
thenElementShouldExist('飲食');
thenAmountShouldBe('$50');
});
// ... existing tests ...
雖然不符合心中預期,但確實是符合指令所述,看來得想個更具體的指令,讓 AI 更懂我的意圖才行。否則這樣的指令提供幾百次,AI 也猜不出我心目中想要的測試樣貌。
那只好以比較具體的「預計實作方向」來提供指令,因為目前新功能完全都還沒實作,AI 也沒 sample code 可以參考。預計會有個 AccountingRepository
的資料儲存庫,記帳 App 可以從中取得上一次的記帳資料。
最後提供了如下具體的指令:
幫我根據 Scenario: 瀏覽已紀錄的帳目 這項場景,
新增一筆測試在 @AccountingApp.test.tsx
此測試可以假設資料是來自 AccountingRepository 的 getEntries(),
所以可先在 AccountingRepository 塞一筆帳目資料
並給了以下的生成結果(有用 inline prompt 微調一點)
import { AccountingRepository } from './AccountingRepository';
// ... 現有的代碼 ...
// 在描述塊的開始添加 mock
jest.mock('./AccountingRepository');
it('displays pre-existing accounting entries', () => {
const mockEntries: HistoryItem[] = [
{ id: 1, category: '飲食', amount: 100 },
{ id: 2, category: '日用品', amount: 200 },
];
(AccountingRepository.getEntries as jest.Mock).mockResolvedValue(mockEntries);
whenRender();
thenCategoryShouldHave('飲食');
thenElementShouldExist('$100');
thenCategoryShouldHave('日用品');
thenElementShouldExist('$200');
thenTotalAmountShouldBe('$300');
});
最終看起來不錯,測試先到目前這樣就好,晚點等測試通過後,再來整理一下。至於測試的部分,因為功能根本還沒寫,當然是沒通過囉 🔴 。
測試已經寫了,緊接是把功能給完善。上次已經決定好開發的方向:資料來自於 AccountingRepository.getEntries()
既然如此,目標也相當明確了,不用猶豫指令怎麼給,就直接把具體指令提供給 AI:
請幫我在 App 實作 AccountingRepository,
這個 AccountingRepository 有個 getEntries 方法可以取得先前紀錄的帳目資料,作為帳目的「初始值」
一樣是用 claude-sonnet 的模型幫我產出這個「新功能」,最後產出了如下程式碼實作
// ... existing imports ...
import { useEffect } from "react";
// 新增 AccountingRepository 介面
interface AccountingRepository {
getEntries: () => Promise<HistoryItem[]>;
}
// 實作 AccountingRepository
const accountingRepository: AccountingRepository = {
getEntries: async () => {
// 這裡模擬從 API 或本地存儲獲取數據
// 在實際應用中,您可能會從後端 API 或本地存儲獲取數據
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: 1, amount: 100, category: "飲食" },
{ id: 2, amount: 200, category: "交通" },
{ id: 3, amount: 300, category: "娛樂" },
]);
}, 1000);
});
},
};
const App: React.FC = () => {
// ... existing state declarations ...
// 使用 useEffect 來獲取初始帳目數據
useEffect(() => {
const fetchInitialEntries = async () => {
try {
const initialEntries = await accountingRepository.getEntries();
setHistory(initialEntries);
} catch (error) {
console.error("Failed to fetch initial entries:", error);
}
};
fetchInitialEntries();
}, []);
// ... rest of the component code ...
};
export default App;
雖然看起來還可以,但實作起來有點小複雜,我們晚點再來用 useQuery
這個 library 處理這部分的 query 實作。自己刻 useEffect 和 try…cath 來做 fetch 這件事,已經不是目前的主流寫法了,還是拿其他 library 已經處理好的來用為佳。
接著,我希望實作 interface AccountingRepository
的物件是個 class ,於是用 inline prompt 下指令直接改該處實作 :「我希望用 class 實作 AccountingRepository
」。
這邊完成之後,我希望程式碼更乾淨,於是把這個 Repository 搬到另外一個檔案,這樣更「乾淨清楚」一點。而且 AI 預設回傳一些「假資料」,但我現在用不到,先註解起來作為稍後的參考使用。
./repository/AccountingRepository.ts
import { HistoryItem } from "../types";
interface AccountingRepository {
getEntries: () => Promise<HistoryItem[]>;
}
export class AccountingRepositoryImpl implements AccountingRepository {
async getEntries(): Promise<HistoryItem[]> {
return Promise.resolve([]);
// return new Promise((resolve) => {
// setTimeout(() => {
// resolve([
// { id: 1, amount: 100, category: "飲食" },
// { id: 2, amount: 200, category: "交通" },
// { id: 3, amount: 300, category: "娛樂" },
// ]);
// }, 1000);
// });
}
}
雖然實作到這邊了,但測試還沒有通過,反而還多壞了一個原先的測試。這是因為新加上了資料取得的功能,這個測試因而壞掉,被新來的 fetch 所影響「渲染狀態順序」,而造成非預期行為的測試不通過。
知道可能會這樣,但不太確定該怎麼做。也是請 AI 幫忙 debug 看看,最後在驗證這一塊包起了 waitFor 的函式,這好像有喚起了一點記憶,想起來 waitFor 可以做到「等待 UI 渲染」才做下個步驟。
it('allows editing of an existing record by double-clicking', async () => {
await whenRender();
//...
await waitFor(() => {
thenCategoryShouldHave('飲食');
thenAmountShouldBe('$20');
thenElementShouldNotExist('娛樂');
thenElementShouldNotExist('$10');
});
});
但事情沒有憨人想得這麼簡單,測試還是一樣 🔴 沒通過,不過提供了一個方向,我應該要在「觸發行為」的時候,等待上個組件渲染好才對。試著在 double click 這裡用了 findByText
做 await 等待,等組件變化之後才做下一步。
async function whenDoubleClickRecord(recordText: string) {
const recordElement = (await screen.findByText(recordText)).parentElement;
//...
}
果不其然,賓果! 測試通過了!這樣改果然沒錯。
至於新功能的測試,改一下寫法即可,從 mock 改成 spyOn,將 AccountRepository 的回傳值給「假冒」起來,只需關注這個 repository 有確實給我值就好,這樣我就能測試 App 是不是真的有拿這個值來用。
最後測試長這樣,最後的驗證一樣是需要用 waitFor 等待,等狀態(state)變化都完成後,再來驗組件事否存在。
it('displays pre-existing accounting entries', async () => {
const mockEntries: HistoryItem[] = [
{ id: 1, category: '飲食', amount: 100 },
{ id: 2, category: '日用品', amount: 200 },
];
jest.spyOn(AccountingRepositoryImpl.prototype, 'getEntries').mockResolvedValue(mockEntries);
await whenRender();
await waitFor(() => {
thenCategoryShouldHave('飲食');
thenElementShouldExist('$100');
thenCategoryShouldHave('日用品');
thenElementShouldExist('$200');
thenTotalAmountShouldBe('$300');
});
});
還沒有要實作紀錄如何取得,所以 getEntries()
這部分可以先放著不管。如上面所提,我想先把 fetch 資料那塊,用 react-query
這個函式庫做處理,程式碼可以相對簡潔並可靠地獲取資料。
下了「幫我用 react-query 這個套件重構這一塊」的 prompt,AI 很忠實的幫我將剛剛用 useEffect 實作 fetch 那一塊,改成用 useQuery
的方式獲取資料,並設定到 state 上。
AI 「很貼心」的為我們加上 loading 時候會出現的 Loading…
文字,如此一來可以在 UI 上防止使用者「誤觸」,也可以用在測試中「確保資料已經 loading 完成」
const { data: initialEntries, isLoading, error } = useQuery<HistoryItem[]>(['initialEntries'], async () => {
try {
return await accountingRepository.getEntries();
} catch (error) {
console.error("Failed to fetch initial entries:", error);
throw error;
}
});
useEffect(() => {
if (initialEntries) {
setHistory(initialEntries);
}
}, [initialEntries]);
//...
if(isLoading) {
return ...
}
因應上面的功能重構,測試也要調整一下寫法,在 whenRender
時需要多判斷 Loading 的文字已經消失。此外,因為 whenRender
變為 async 函式,所以有用到的地方都要調整一下。這樣調整之後,測試也都通過了 🟢。
async function whenRender() {
//...
await waitFor(() => {
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});
}
//...
await whenRender();
後來想想,根據「Loading…」這個文字來判斷,其實不太可靠。以後如果文字換成「載入中」,那測試還要回頭調整,這是我們開發者最不樂見的:「過於依賴 UI 細節」。
所以我想修改一下,趁現在記憶猶新,不要把這種明顯的「地雷」埋給未來的自己。於是想說該怎麼去驗證「已經不是 loading 狀態了?」,即便加了新功能,有什麼是可以在程式被修改後也「不太會變動」的判斷方式? 那應該就是判斷「最外層」的 App Wrapper 是否存在了。如果沒特別的大幅度更動,基本上都不太會動到這一塊的程式碼。
App
<div data-testid="accounting-app" ...>
測試
function whenRender() {
//...
await waitFor(() => {
expect(screen.getByTestId("accounting-app")).toBeInTheDocument();
});
}
重構到此,程式碼變得精簡許多,測試也隨之修正並調整好了。
剩下哪些?現在有了「取得」資料,但新增、修改和刪除都還沒有做,現在只算是個做到一半的記帳軟體。甚至連裡面的資料紀錄實作,都還沒有決定怎麼做。
但目前這樣「一個功能」算是完成了,姑且就先做到這邊,預計下次會先做「新增」的部分,這樣 App 就可以「自給自足」,可以在 App 內新增帳目,下次打開 App 就能看到上次紀錄的帳目了。
這次介紹的 PDCA 開發循環,是個比較小的循環,從個小功能開始規劃、製作、重構以及下一步的盤點。藉由這樣一個功能的完整開發週期,可以把功能「完整」交付出去,甚至要上版也都沒問題,而不必擔心寫到一半,有哪些功能因而壞去。
所以,小循環還是大循環好? 小循環的開發方式,更貼近敏捷開發的精神,小步驟多增量的方式做開發製作,一個「完整」的需求,可能會經歷數次的 PDCA 循環。像這次的功能,就會分成好幾次的 PDCA 循環製作。
而大循環則是每個 PDCA 階段要做的事,都一起做一做,更貼近瀑布開發方式的精神。如這次沒有開發到的「新增、修改和刪除」,可能就會跟著「讀取」這個功能一起做開發。
如果是大循環的開發法,那有可能這篇介紹到需求+測試就差不多了,隔天(按照遺忘曲線來說,隔天記憶忘了 7 成)回頭看,至少有一半已經忘記寫什麼了…。
所以我會傾向更小的循環,一個循環在半天到一天之內可以完成,趁著記憶猶新趕快開發,如此一來更有效率呢!